Skip to content

fix(service-settings)!: Setup's default language offers and accepts the languages the deployment supports, not four hard-coded ones - #30446

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-g5rn8k
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-g5rn8k

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

维护者速读

  • 改了什么: Setup → 本地化 →「默认语言」不再写死四种语言,改成部署实际支持的语言:和用户右上角语言菜单读同一个来源(i18n 服务的 getLocales(),即 GET /api/v1/i18n/locales),选项每次打开页面、每次保存时现读,所以后加的翻译项(比如越南语)马上就能选;每个选项用该语言自己的名字显示(简体中文、日本語、Tiếng Việt)。
  • 为什么改: 法国客户有完整法语包、用户菜单能选法语,但组织默认语言只能在英/中/日/西四种里选,存 fr-FR、vi-VN 一律被拒,每个新用户都从公司没选的语言起步。
  • 风险与回滚: 这是一次收窄(Clause-②: yes (narrowing)):部署不支持的语言以后存不进去,例如应用只声明 en、zh-CN 时,以前能存的 ja-JP、es-ES 现在被拒。已经存着的旧值照常生效、照常可读,页面上显示为「(已不再提供)」,保存别的字段不受影响;只有再次写入它才会被拒。en-US 这类老写法在部署提供 en 时照常接受。没有 i18n 服务的组合仍用原来四种。回滚即撤本 PR。
  • 先看: packages/services/service-settings/src/localization-locale-options.ts(来源、命名、接受规则、"已不再提供");settings-service.ts 里新的 registerOptionSource / optionTableOf / servedManifest 三段,以及保存和环境变量两处改成问 optionTableOf 的两行;settings-service-plugin.ts 在注册清单之前注册这个来源的那一段。
  • 两分钟验证: pnpm --filter @objectstack/service-settings exec vitest run --maxWorkers=2 src/localization-locale-options.test.ts,预期 14 条全绿,其中"后加的翻译项不重启即可保存"和"已不再提供的旧值照常可读并标注"两条。
  • 需要你做的: 这是 BREAKING 收窄,合入需要你本人同意(合约审查由席位安排)。

Fixes #30286

Clause-②: yes (narrowing) — SettingsService gains registerOptionSource(namespace, key, source) and the package exports the SettingsOptionSource, SettingsOptionSourceAnswer and SettingsServedOption types; getNamespace takes an optional third display argument. The save of localization.locale (PUT /api/settings/localization, SettingsService.setMany) and an OS_LOCALIZATION_LOCALE override now accept the i18n service's locale list instead of the declared en-US, zh-CN, ja-JP, es-ES: a language the deployment supports is accepted where it was refused (fr-FR, vi-VN), and one of the four the deployment does not support is refused where it was accepted (ja-JP on an app that declares i18n.supportedLocales: ['en', 'zh-CN'], as examples/app-showcase and examples/app-crm do).
Metadata-protocol: none
Skills-docs: none — git grep -n "localization.locale" skills content/docs answers one line, content/docs/permissions/authentication.mdx:474 (SMS bodies fall back to the setting), which lists no values and stays true; skills/** names the setting nowhere.

What changes

A settings key can serve its options from the running system (SettingsService, settings-service.ts, settings-service.types.ts). Measured first: a settings option table was recorded once, from the manifest, at registerManifest; the valueDomain vocabulary is closed (three members) and bcp47_locale is refused by name in packages/spec/src/system/settings-manifest.test.ts, because this key's options were meant to be the supported set, not a standard. So no existing mechanism and no spec change: a service-level registration, registerOptionSource(namespace, key, source). The source answers { options, admits(value) }, or undefined to leave the declared table standing.

  • Save (validatePatch) and OS_* override (effectiveEnvOverride) both ask one helper, optionTableOf, for the values offered and the judgment of a written value: the source's answer when it has one, else the declared table through firstRejectedOption, unchanged. Each site changes by two lines; the refusal is the existing invalid_option with constraint.allowed listing the offered values. The TOUCH gate is unchanged, so a stored value is judged only by a save that writes it.
  • Registration-time env audit (auditEnvOverrides) skips the options family of a sourced key: the source reads a service that registers later in the boot, so a verdict there would come from the table it replaces. The override is judged when read, by the same helper (AGENTS.md, Startup registry reads).
  • Page read (getNamespace, optional display.locale; the GET /api/settings/:namespace route passes the request's language, one line) serves a copy of the manifest whose sourced specifiers carry the source's options for the value in effect. The registered manifest is never changed. GET /api/settings (the manifest list) still serves the declared table.
  • A source that throws is answered as absent (declared table) and logged once per cause at warn.

localization.locale's source (localization-locale-options.ts, new). Offered codes = the i18n service's getLocales(), de-duplicated, in its order: the app's declared i18n.supportedLocales, else every loaded bundle and authored translation item, the list GET /api/v1/i18n/locales serves to the language menu.

  • Names in their own language: the platform locale table's name (PLATFORM_LOCALES, spec(system): export the platform locale table PLATFORM_LOCALES and its read aliases (phase 1 of #29428) #30001) for the language the code means through platformLocaleTagOf, else Intl.DisplayNames([code]) with fallback: 'none', capitalized, else the code. The console's language menu names locales the same way (ui/packages/app-shell/src/layout/LocaleSwitcher.tsx, localeLabel).
  • Accepted: each offered code, and a stored spelling of an offered table language (en-US where en is offered, zh-CN where zh-Hans is), through the table's read aliases. Without this the manifest default en-US would not be a member on any deployment whose list says en, which is all three examples. A code the table does not alias is accepted only as spelled (fr-FR is not fr).
  • The value in effect: a stored second spelling is served under its own spelling so the select shows it chosen; a stored value no longer offered is served as an extra option named NAME (no longer offered) in the reader's language (en, zh-CN, ja-JP, es-ES), so the page shows it rather than a blank select.
  • No i18n service, or one reporting nothing ⇒ the declared four stand.

Wiring (settings-service-plugin.ts). init registers the source before the manifests, with ctx.getService('i18n') asked at each use.

Translations (translations/zh-CN.ts). The bundle translated the four option names (英语(美国), 日语, …). The console's option-label lookup prefers the bundle over the served name, so those entries would have named each language in the reader's language; they are removed. The other three bundles carried none. The coverage guard already excludes option labels for this reason (settings-translation-coverage.test.ts header).

Manifest (localization.manifest.ts). The declared four stay as the no-i18n fallback; a comment says what a running deployment serves instead.

Readers of localization.locale (the card's question)

None breaks on a locale outside the four; each was read on origin/main fe835b4, not exercised end to end:

Reader Where With vi-VN
Execution context locale (number/date formatting, resolveLocalizationContext) packages/core/src/security/assemble-execution-context.ts, resolve-authz-context.ts Intl formats any BCP 47 tag.
Auth email locale plugin-auth/src/auth-plugin.ts (applyLocalizationLocale), auth-manager.ts (normalizeAuthEmailLocale) Passed through unchanged; EmailService.sendTemplate's ladder finds no vi-VN row and falls to en-US (plugin-email/src/email-service.ts). en still maps to en-US.
Auth SMS bodies plugin-auth/src/phone-sms-texts.ts phoneSmsLocaleChain ends at en; never renders nothing.
Seed dataset locale packages/runtime/src/seed-locale.ts → metadata-protocol/src/seed-loader.ts (filterByLocale) Datasets scoped to other locales are skipped and named in one log line; unscoped ones load, as for any unmatched locale.
Activity summaries, @mention titles plugin-audit/src/audit-plugin.ts, audit-writers.ts i18n t() falls back to the default locale.
Messaging template locale service-messaging/src/messaging-service-plugin.ts Names the setting in a comment only; reads i18n.getDefaultLocale() at delivery, unset lands the en-US default. Unaffected.
GET /auth/me/localization plugin-hono-server/src/current-user-endpoints.ts Echoes the value; the console shows English for a language with no console pack, as its language menu already does.

Tests

New src/localization-locale-options.test.ts (14 tests), on a real LiteKernel boot of SettingsServicePlugin with a stand-in i18n service whose list the cases change, through the real settings routes (GET / PUT /api/settings/:namespace):

  • serves the list, named in each language, with en-US in effect shown as the en option; the registered manifest unchanged;
  • accepts fr-FR when offered; follows the list at runtime (vi-VN refused, then accepted after the list grows, no reboot);
  • refuses an unoffered ja-JP: 400 SETTINGS_VALIDATION, invalid_option at locale, constraint.allowed: 'en, zh-CN', nothing written;
  • accepts zh-CN / en-US where zh-Hans / en are offered, refuses fr-FR where fr is;
  • a stored ja-JP the list drops: still the value, served as 日本語 (已不再提供) for a zh-CN reader, a save of fiscal_year_start passes, a re-write of ja-JP is refused;
  • empty list ⇒ the declared four;
  • OS_LOCALIZATION_LOCALE=vi-VN in force while offered, not in force once the list drops it; not reported by the registration-time audit while a declared-table key (fiscal_year_start) still is;
  • no source ⇒ declared table; a throwing source ⇒ declared table, one warn;
  • localeSelfName: table names, the code for an unnamed tag, a malformed tag and a runtime without Intl.DisplayNames, and the runtime's ICU name for vi-VN.

Ablation through scripts/ablation-replace.mjs: optionTableOf made to ignore the source ⇒ 6 of 14 red; restored, blob equal to HEAD.

Package: pnpm --filter @objectstack/service-settings test 69 files, 1170 passed; typecheck exit 0.

Acceptance notes

  • Setup's renderer needs no change: ui/apps/console/src/pages/settings/SettingsField.tsx draws a select from the served spec.options and names each through labels.optionLabel, which falls back to the served label when the bundle has no entry. Read, not changed (ui/** is out of scope).
  • First-run setup checklist for administrators, replacing the currency and time zone dialog #30206 (first-run checklist language row) should read the same list: GET /api/settings/localization's served locale options, or the i18n service's getLocales() server-side. No change here.
  • The new component's pins are in its own test file (localization-locale-options.test.ts); the settings routes' shared harness (settings-routes.test.ts) is not extended.
  • GET /api/settings (the manifest list the Setup hub reads for its navigation) still carries the declared four; only the namespace read serves the live list.

Verification

All at head e1c377ef (the branch with origin/main merged), after pnpm install --frozen-lockfile (exit 0) and a full build (turbo run build --filter='!@objectstack/docs', 119/119, under the verify lock):

  • pnpm --filter @objectstack/service-settings test: 69 files, 1170 passed. typecheck: exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands derived 63 families from the change set; each run with its exit recorded before any pipe: 63 of 63 exit 0 (check-plugin-teardown-shape --self-test first answered exit 3, PREREQUISITE NOT MET, on the shallow clone; it exits 0 after fetching its pinned fixture commit). --ran: 63 derived, 63 run, 0 NOT-MEASURED, exit 0.
  • pnpm lint: exit 0.
  • Full dogfood (BREAKING, narrowing arm), pnpm --filter @objectstack/dogfood test under the lock: exit 0, 419 files passed and 3 skipped, 3492 tests passed and 36 skipped.
  • CI check-runs: not yet reported at opening.

Generated by Claude Code

@github-actions github-actions Bot added the size/l label Nov 5, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-settings, touching 33 documentable anchor(s).

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via getLocales (sdk, the bare tail of client method i18n.getLocales, bound to GET /api/v1/i18n/locales), i18n.getLocales (sdk, the route ledger binds it to GET /api/v1/i18n/locales))
  • content/docs/data-modeling/drivers.mdx (via SettingsService (symbol, a top-level class))
  • content/docs/kernel/runtime-services/push-service.mdx (via /devices/:deviceId (route, bridged from symbol getLocales — its route source's handler names it))
  • content/docs/kernel/services-checklist.mdx (via getLocales (symbol, a method of interface LocaleListService), getLocales (sdk, the bare tail of client method i18n.getLocales, bound to GET /api/v1/i18n/locales), unregisterDevice (sdk, the bare tail of client method notifications.unregisterDevice, bound to DELETE /notifications/devices/:deviceId), /api/v1/i18n/locales (route, a path literal in a comment on a changed line))
  • content/docs/protocol/kernel/config-resolution.mdx (via SettingsService (symbol, a top-level class), registerSettingsRoutes (symbol, a top-level function))
  • content/docs/protocol/kernel/http-protocol.mdx (via getLocales (symbol, a method of interface LocaleListService), getLocales (sdk, the bare tail of client method i18n.getLocales, bound to GET /api/v1/i18n/locales))
  • content/docs/protocol/kernel/index.mdx (via SettingsService (symbol, a top-level class), SettingsServicePlugin (symbol, a top-level class))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via unregisterDevice (sdk, the bare tail of client method notifications.unregisterDevice, bound to DELETE /notifications/devices/:deviceId))
  • content/docs/releases/v18/18-0.mdx (via /api/v1/i18n/locales (route, a path literal in a comment on a changed line))
  • content/docs/releases/v18/18-3.mdx (via unregisterDevice (sdk, the bare tail of client method notifications.unregisterDevice, bound to DELETE /notifications/devices/:deviceId), /devices/:deviceId (route, bridged from symbol getLocales — its route source's handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 677ac3bbc026ae9a6d6049b3dd1ad0f5e66bb4d7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 74481d1eaf566530496115067132dff33de57478 — the merge of head e1c377ef0e8054f799a13c64482a5b04a998304a into base 677ac3bbc026ae9a6d6049b3dd1ad0f5e66bb4d7, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 74481d1eaf566530496115067132dff33de57478 && git checkout 74481d1eaf566530496115067132dff33de57478
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 677ac3bbc026ae9a6d6049b3dd1ad0f5e66bb4d7 e1c377ef0e8054f799a13c64482a5b04a998304a && git checkout -B drift-repro 677ac3bbc026ae9a6d6049b3dd1ad0f5e66bb4d7 && git merge --no-ff e1c377ef0e8054f799a13c64482a5b04a998304a

node scripts/docs-audit/affected-docs.mjs --json 677ac3bbc026ae9a6d6049b3dd1ad0f5e66bb4d7

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 677ac3bbc026ae9a6d6049b3dd1ad0f5e66bb4d7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

Contract-review-claim: session_01Utp6CZMkHvuDeVYgTD73qF · head e1c377e · 2026-11-05T11:46Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e1c377ef0e8054f799a13c64482a5b04a998304a
Local-runs: none
Scope: packages/services/service-settings/src/index.ts, packages/services/service-settings/src/localization-locale-options.test.ts, packages/services/service-settings/src/localization-locale-options.ts, packages/services/service-settings/src/manifests/localization.manifest.ts, packages/services/service-settings/src/settings-routes.ts, packages/services/service-settings/src/settings-service-plugin.ts, packages/services/service-settings/src/settings-service.ts, packages/services/service-settings/src/settings-service.types.ts, packages/services/service-settings/src/translations/zh-CN.ts + packages/spec/src/system/platform-locales.ts, packages/spec/src/system/settings-manifest.zod.ts, packages/spec/src/system/settings-manifest.test.ts, packages/spec/src/system/i18n-resolver.ts, packages/core/src/fallbacks/memory-i18n.ts, packages/services/service-i18n/src/file-i18n-adapter.ts, packages/runtime/src/app-plugin.ts, packages/runtime/src/domains/i18n.ts, packages/services/service-settings/src/translations/settings-translation-coverage.test.ts, packages/core/src/security/resolve-authz-context.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/phone-sms-texts.ts, packages/plugins/plugin-auth/src/idp-locale-claim.ts, packages/plugins/plugin-email/src/email-service.ts, packages/plugins/plugin-audit/src/audit-writers.ts, packages/runtime/src/seed-locale.ts, packages/plugins/plugin-hono-server/src/current-user-endpoints.ts, ui/apps/console/src/pages/settings/SettingsField.tsx, ui/apps/console/src/pages/settings/useSettingsLabel.ts, ui/packages/app-shell/src/layout/LocaleSwitcher.tsx, ui/packages/i18n/src/provider.tsx

Inputs: card #30286 (body, 5 comments through the dev report 6657161883), PR #30446 (body, 10-file list, net diff against main at the merge base dbf53efe), the check-runs on the head (read at 2026-11-05T12:07Z; the last five Test Core shards were still running at the first read and were re-read before this verdict). The cited code was read with git show on the head's objects — no checkout, build, test or gate run.

① Derived judgments

Each accept-set or public-surface change the diff implies, named right or wrong against the code read:

  1. Save of localization.locale (validatePatch → optionTableOf): accepted set = the i18n service's getLocales() codes, plus a stored spelling that platformLocaleTagOf maps to the same platform-table language as an offered code (en-US where en, zh-CN where zh-Hans/zh, ja-JP where ja, es-ES where es). The declared four stand only when the source answers nothing (no i18n service, empty list, or a throw). Right. The offered set is read at each call, never cached, so a translation item added at runtime on a non-declaring deployment is accepted at once; on a deployment that declares i18n.supportedLocales the list IS the declaration (file-i18n-adapter.ts:244, memory-i18n.ts:220), and the console's language menu reads the same list through GET /api/v1/i18n/locales (provider.tsx:784, filtered only by canResolveLanguage, which admits any well-formed tag once a loader is wired). Same source, same freezing behaviour on both ends.
  2. OS_LOCALIZATION_LOCALE (effectiveEnvOverride → optionTableOf): judged against the same table on every read; the registration-time audit skips a sourced key. Right in steady state; see ④-S2 for the boot window.
  3. Stored value outside the set: no read, boot, env or unrelated-patch path re-judges it. validatePatch's TOUCH gate is unchanged (options check sits inside it; no localization specifier declares a visible that names locale), get/getMany never consult the table, auditEnvOverrides judges env only, and no write path rewrites a row. The namespace read serves it as an extra option labelled NAME (no longer offered) in the reader's language, and a re-write of it is refused. Right.
  4. Legacy spellings: en-US (the manifest default) is admitted on every deployment whose list says en, through PLATFORM_LOCALE_READ_ALIASES; without this the default itself would be outside the set on all three examples/*. Right. A code the table does not name is exact-match only (fr-FR is not fr) — see ④-S3.
  5. Public surface: SettingsService.registerOptionSource(namespace, key, source) (new, last-registration-wins like registerAction), getNamespace(ns, ctx, display?) (third optional argument, source-compatible), and index.ts exports SettingsOptionSource, SettingsOptionSourceAnswer, SettingsServedOption as types. Additive; no published export removed or retyped; service-settings has no api-surface/ snapshot to regenerate. Right. The PR body and changeset name all of them.
  6. GET /api/settings/:namespace serves a copy of the manifest with the sourced key's options replaced; the registered manifest object is untouched (servedManifest). Right. GET /api/settings (listManifests) and getManifest() still state the declared four — see ④-S1.
  7. Failure mode: a throwing source is answered as absent, logged once per namespace.key:cause at warn; an absent i18n service (ctx.getService throwing or returning undefined) is answered as absent. Fail-open to the declared four, never fail-closed, never a crash. Right, and the safer direction for a save door.
  8. Translations: the four zh-CN option labels removed so the served self-names show (useSettingsLabel.ts:154 prefers the bundle key over the served label; the coverage test excludes option labels by its own header, lines 17–20; en, ja-JP, es-ES carried none). Right.
  9. Readers of the setting (the card's question): resolve-authz-context.ts (coerceLocale is trim-only; Intl takes any tag), auth-plugin.ts / auth-manager.ts (normalizeAuthEmailLocale passes a regional tag through unchanged; EmailService falls to DEFAULT_TEMPLATE_LOCALE en-US), phone-sms-texts.ts (phoneSmsLocaleChain ends at en), seed-locale.ts (unmatched locale ⇒ unscoped datasets), audit-writers.ts (t() falls back), current-user-endpoints.ts (echo). None refuses, throws or misformats on a locale outside the four. The dev's table is accurate. One comment in auth-manager.ts:1270 states an invariant this diff retires — ④-S4.

② Semver level

.changeset/30286-default-language-follows-deployment.md: @objectstack/service-settings: patch, Clause-②: yes (narrowing), Metadata-protocol: none, **BREAKING** banner with Before / Now / Unchanged / Remedy, one ADR-0087 marker not-required (no-migration-prescription). The PR body carries the same two declaration lines; the title carries !. Per AGENTS.md step 3 a non-protocol change, widening or breaking, ships patch with its banner and disposition, and none takes patch; the additive exports do not move the kind. Check Changeset, Lint · CI, release & engine gates and Lint · PM dispatch-gates are green on the head. Matches what the diff publishes. Clause-②: the (narrowing) arm is the right one: ja-JP / es-ES on examples/app-showcase and examples/app-crm (supportedLocales: ['en', 'zh-CN']) were accepted and are refused; the Claim's yes (widening) was superseded with the seat's agreement, as the dev report says.

③ Boundary flags

  • open_questions: [] — nothing to answer.
  • Dev deviation 1, narrowing instead of widening: judged in ② and ④; agreed with the seat before code.
  • Dev deviation 2, shared files: settings-service.ts hunks are at auditEnvOverrides, effectiveEnvOverride, the new block after registerLiveStatus, getNamespace and validatePatch; the setMany write loop (the effectiveEnvOverride env-lock throw at 2516) is not touched. Seat 2's fix(service-settings): a clear goes through an env pin, so the allowed-IP lockout recovery survives removing the variable #30448 (card Allowed IP ranges: the documented env recovery cannot clear the stored list, so removing OS_AUTH_ALLOWED_IP_RANGES locks the admin out again at the next restart #30385) also edits settings-service.ts and settings-service.types.ts; no hunk overlaps, but both add to the same files, so whichever lands second rebases. Not a finding against this PR.
  • Dev deviation 3, pins in the component's own test file rather than settings-routes.test.ts: consistent with ruling 6583487846 (a new behaviour's pins in their own describe, no new shared harness).
  • ui/** half: none remains. SettingsField.tsx:630 draws spec.options from the namespace read and renderOptionLabel falls back to the served label. Verified, not taken from the PR body.
  • The card's #30206 note: the first-run checklist is open and not in the tree; the list it should read is the namespace read's served locale options (see ④-S1 for why not GET /api/settings).
  • Fork check: head repo is objectstack-ai/objectstack. Governed surfaces: none in the file list. Governed Surface Queue Guard green.

④ Design judgment

The narrowing, read against the published spec on main. settings-manifest.zod.ts:496 says that without valueDomain the options table is exhaustive and "legal means this deployment ships an adapter for it"; settings-manifest.test.ts:210 refused bcp47_locale precisely because localization.locale's options "ARE the shipped message catalogs — a registry-backed table". The manifest's literal four were a stand-in for that registry. This diff makes the spec's own refusal real at the consumer: the accepted set becomes the deployment's locale list. The capability taken away (ja-JP as the default on a deployment that declares only en/zh-CN) was already dead one step downstream — that deployment's users could not pick Japanese in the menu, and GET /auth/me/localization would hand every renderer a language it cannot show. Not a capability declared and withheld; a declaration made true. The maintainer's word on the card (「立卡」 over the Expected list, "the server accepts exactly those values") rules it. The narrowing holds.

Tried to break it:

  • Stale stored value on a save of another key — TOUCH gate, held. On boot — no stored-row validation anywhere, held. On env override — env value judged, stored row untouched, held. Served to the page with the stored spelling kept — held (options[sameLanguage] keeps the stored value so the select shows it chosen).
  • Source throws mid-save — declared table, one warn, held. i18n missing — ctx.getService guarded both ways, held.
  • Source registered after a manifest — the audit would have judged the declared table; JSDoc says register first and the plugin does. Held for the one registration there is.
  • Intl.DisplayNames absent, malformed tag, unnamed tag — code returned, held; capitalisation matches LocaleSwitcher.localeLabel.

Findings, none a must-fix:

  • S1 (should-fix, follow-up card) — Two statements of the table remain: getManifest(), listManifests() and GET /api/settings still carry the declared four as options, while only the namespace read serves the live list. The spec doc at settings-manifest.zod.ts:490–500 still describes the declared options as the save boundary for every select without a domain. A consumer reading the manifest list (the Setup hub, #30206's language row, an SDK caller) is told a table that is not the one the save judges. packages/spec/** is outside this dispatch; the seat should file the spec card that either notes the option-source carve-out beside valueDomain or lets listManifests serve sourced options too.
  • S2 (should-fix) — Boot window on the env door. app-plugin.ts applies setSupportedLocales and the app bundles in start(), and every platform plugin (this one included, settings-service-plugin.ts:299–315) pushes its bundle at kernel:ready. On a deployment that declares nothing, a kernel:ready reader (auth-plugin.ts:1333 applyLocalizationLocale, seed-locale.ts:137) can ask before the list is whole, get the declared four, see OS_LOCALIZATION_LOCALE=vi-VN IGNORED and record that locale (setDefaultSmsLocale, applyEmailLocale) until the next settings-change event — an env override coming into force a tick later fires none. reportRejectedEnvOverride also dedupes on env=value, so its one error line says IGNORED for the boot's lifetime while later reads put the override in force. AGENTS.md "Startup registry reads" class. Narrow (env override + locale outside the four + non-declaring deployment), and pre-PR the same value was always refused, so no regression — but the new capability is only delivered after the boot settles. A ready-time re-read or a source that answers undefined until kernel:ready would close it.
  • S3 (should-fix) — Two matchers in one product. admits is exact match plus platformLocaleTagOf; idp-locale-claim.ts closes a locale against the same getLocales() with resolveLocale (exact → case → base → variant), and runtime/src/domains/i18n.ts falls back on translations the same way. So sys_user.locale can hold fr (resolved from fr-FR) while Setup refuses fr-FR where fr is offered, and admits is case-sensitive where resolveLocale is not. No stored legacy value is affected (the four are all table-aliased), so this is a consistency point, not a narrowing fault; naming the chosen matcher once, or reusing resolveLocale, is the fix.
  • S4 (should-fix) — auth-manager.ts:1266–1272: "AUTH_EMAIL_TEMPLATE_LOCALES … are also the four options the localization.locale setting offers, so the two ends agree by construction". After this diff they agree by normalizeAuthEmailLocale's pass-through and EmailService's en-US rung, not by construction. The reader still behaves (judged in ①-9); the comment now states a retired invariant. Outside the dispatch's file surface; one-line follow-up.

Check-runs on the head at the final read: every completed run is success or skipped, Test Core shards included; none failure, cancelled or timed_out.

Implemented-by: claude/project-thread-g5rn8k
Reviewed-by: session_01Utp6CZMkHvuDeVYgTD73qF

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

席位意见:ACCEPT(#30286)。Setup 的「默认语言」现在给出并接受部署实际支持的语言(和语言菜单读同一个列表,加了翻译就自动出现),名称用该语言自称。已保存但不再提供的值照常可读,标「已不再提供」,不会被替换。部署不支持的语言今后保存会被拒(收窄)。

席位核对

Head-sha: e1c377ef0e8054f799a13c64482a5b04a998304a · read 2026-11-05T12:09Z

Item Reading
Shape Draft → main, author objectstack-fleet[bot] (relay pr_create); attribution block, then signature line, Fixes #30286, Clause-② / Metadata-protocol at line start. 10 files, +650/−14. ⛔ packages/spec/** and ui/** untouched; no ui half (Setup's SettingsField.tsx draws served options).
Ruling The maintainer's 「立卡」 over the card's Expected list; triage retriage 6652874310 (fix site service-settings); claim on #30286; dev report 6657161883. The dev's report says "seat agreed (10:55Z)"; the seat's agreement was at 09:55Z.
Gates (check-runs on this head) Every run completed success or skipped, Test Core shards included; none failed. Dev's local runs: 63/63 derived gates exit 0, lint 0, full dogfood exit 0, 6 of 14 ablations red then restored.
Contract review CONTRACT_REVIEW_TIER record 6657571849 on this head: PASS, no must-fix. Accepted set = the i18n service's getLocales() plus platform-table aliases, read at each use; a stored stale value is never re-judged on read, boot, env or an unrelated patch; fail-open to the declared four when the source throws or is absent.
Should-fixes, seat side S1 (manifest list and spec doc still state the declared table): filed as #30455. S2 (env boot window), S3 (two locale matchers), S4 (stale comment in auth-manager.ts): filed together as #30456. Both serial after this PR.
Overlap Seat 2's work in settings-service.ts (write-loop env-lock branch) is untouched; no overlapping hunk.
Semver @objectstack/service-settings: patch with !, BREAKING banner, Clause-②: yes (narrowing), Metadata-protocol: none, one adr-0087: not-required (no-migration-prescription) marker; judged right by the record.
Landing Not governed, ≤ 3000 lines. ⛔ BREAKING (!): the seat does not queue it until the maintainer's own word on this landing.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

维护者速读

  1. 改了什么:Setup → 本地化 →「默认语言」不再写死四种,改成部署实际支持的语言,和语言菜单读同一来源(i18n getLocales()),每次打开和保存时现读,后加的翻译项马上可选;选项用该语言自己的名字显示。
  2. 为什么改:法语包完整、用户菜单能选法语,但组织默认语言只能在英/中/日/西里选,存 fr-FR、vi-VN 被拒,新用户都从公司没选的语言起步。
  3. 风险:收窄,部署不支持的语言以后存不进去(如应用只声明 en、zh-CN 时的 ja-JP);已存旧值照常生效可读、标「(已不再提供)」,保存别的字段不受影响;en-US 等老写法在提供 en 时照常接受;没有 i18n 服务时仍用原来四种。
  4. 先看:service-settings/src/localization-locale-options.ts,以及 settings-service.ts 新的 registerOptionSource / optionTableOf / servedManifest。
  5. 两分钟验证:pnpm --filter @objectstack/service-settings exec vitest run --maxWorkers=2 src/localization-locale-options.test.ts,14 条全绿。
  6. 需要你做的:无,席位落地。合约审查 PASS 6657571849 在案,四条 should-fix 已立卡 spec(settings): a select's options can come from a registered option source, but the manifest doc and listManifests still describe the declared table as the save boundary #30455、service-settings: the default-language option list has a boot window on OS_LOCALIZATION_LOCALE, and its matcher differs from the IdP locale claim's #30456;按 maintainer 13:47Z「技能不需要我审核的不应该等我」,不属 Tier H、不超 3000 行。

席位意见:ACCEPT(#30286)。Setup 的「默认语言」现在给出并接受部署实际支持的语言(和语言菜单读同一个列表,加了翻译就自动出现),名称用该语言自称。已保存但不再提供的值照常可读,标「已不再提供」,不会被替换。部署不支持的语言今后保存会被拒(收窄)。

席位核对

Head-sha: e1c377ef0e8054f799a13c64482a5b04a998304a · read 2026-11-05T13:55Z

Item Reading
Shape Draft → main, author objectstack-fleet[bot] (relay pr_create); attribution block, then signature line, Fixes #30286, Clause-② / Metadata-protocol at line start. 10 files, +650/−14. ⛔ packages/spec/** and ui/** untouched; no ui half (Setup's SettingsField.tsx draws served options).
Ruling The maintainer's 「立卡」 over the card's Expected list; triage retriage 6652874310 (fix site service-settings); claim on #30286; dev report 6657161883. The dev's report says "seat agreed (10:55Z)"; the seat's agreement was at 09:55Z.
Gates (check-runs on this head) 48 completed: 35 success, 13 skipped, 0 failed (re-read at landing).
Contract review CONTRACT_REVIEW_TIER record 6657571849 on this head: PASS, no must-fix. Accepted set = the i18n service's getLocales() plus platform-table aliases, read at each use; a stored stale value is never re-judged on read, boot, env or an unrelated patch; fail-open to the declared four when the source throws or is absent.
Should-fixes, seat side S1 (manifest list and spec doc still state the declared table): filed as #30455. S2 (env boot window), S3 (two locale matchers), S4 (stale comment in auth-manager.ts): filed together as #30456. Both serial after this PR.
Overlap Seat 2's work in settings-service.ts (write-loop env-lock branch) is untouched; no overlapping hunk.
Semver @objectstack/service-settings: patch with !, BREAKING banner, Clause-②: yes (narrowing), Metadata-protocol: none, one adr-0087: not-required (no-migration-prescription) marker; judged right by the record.
Landing Not governed (check-governed-merges --pr 30446: 0 of 10 paths, re-read 2026-11-05T13:55Z), ≤ 3000 lines. BREAKING (!), and neither Tier H nor over 3000 lines, so per pm-dispatch the seat lands it: pr_ready + automerge_enable, on the maintainer's word 13:47Z 「技能不需要我审核的不应该等我」.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Default language in Setup offers the languages the deployment supports, instead of four hard-coded ones

1 participant